05 | 牛刀小试:如何搭建优惠券模板服务?

讲述:姚秋辰

时长31:58大小29.28M

你好,我是姚秋辰。
今天我们来动手搭建优惠券平台的实战项目。为了让你体验从 0 到 1 的微服务改造过程,我们先使用 Spring Boot 搭建一个基础版的优惠券平台项目,等你学习到 Spring Cloud 的时候,我们就在这个项目之上做微服务化改造,将 Spring Cloud 的各个组件像添砖加瓦一样集成到项目里。
如果你没有太多 Spring Boot 的相关开发经验,通过今天的学习,你可以掌握如何通过 Spring Boot 组件快速落地一个项目。如果你之前了解过 Spring Boot,那么今天的学习不仅可以起到温故知新的作用,你还可以从我分享的开发经验里得到一些启发。
03 讲中,我们介绍了优惠券平台的功能模块。我们说过,在用户领取优惠券的过程当中,优惠券是通过券模板来生成的,因此,优惠券模板服务是整个项目的底层基础服务。今天咱就直接上手搭建这个服务模块:coupon-template-serv。不过在此之前,我们先来看看整体的项目结构是怎样搭建的。

搭建项目结构

我把整个优惠券平台项目从 Maven 模块管理的角度划分为了多个模块。
在顶层项目 geekbang-coupon 之下有四个子模块,我先来分别解释下它们的功能:
coupon-template-serv: 创建、查找、克隆、删除优惠券模板;
coupon-calculation-serv:计算优惠后的订单价格、试算每个优惠券的优惠幅度;
coupon-customer-serv:通过调用 template 和 calculation 服务,实现用户领取优惠券、模拟计算最优惠的券、删除优惠券、下订单等操作;
middleware:存放一些与业务无关的平台类组件。
在大型的微服务项目里,每一个子模块通常都存放在独立的 Git 仓库中,为了方便你下载代码,我把所有模块的代码都打包放到了这个代码仓库里,你可以在这里找到课程各阶段对应的源代码。
在每一个以“-serv”结尾的业务子模块中,我从内部分层的角度对其做了进一步拆分,以我们今天要搭建的 coupon-template-serv 为例,它内部包含了三个子模块:
coupon-template-api:存放公共 POJO 类或者对外接口的子模块;
coupon-template-dao:存放数据库实体类和 Dao 层的子模块;
coupon-template-impl:核心业务逻辑的实现层,对外提供 REST API。
你会发现,我把 coupon-template-api 作为一个单独的模块,这样做的好处是:当某个上游服务需要获取 coupon-template-serv 的接口参数时,只要导入轻量级的 coupon-template-api 模块,就能够获取接口中定义的 Request 和 Response 的类模板,不需要引入多余的依赖项(比如 Dao 层或者 Service 层)
这就是开闭原则的应用,它使各个模块间的职责和边界划分更加清晰,降低耦合的同时也更加利于依赖管理。
搭建好项目的结构之后,接下来我们借助 Maven 工具将需要的依赖包导入到项目中。

添加 Maven 依赖项

这里你要注意一下,添加 Maven 依赖项需要遵循“从上到下”的原则,也就是从顶层项目 geekbang-coupon 开始,顺藤摸瓜直到 coupon-template-serv 下的子模块。首先,我们来看看顶层 geekbang-coupon 依赖项的编写。

编写 geekbang-coupon 依赖项

geekbang-coupon 是整个实战项目的顶层项目,它不用操心具体的业务逻辑,只用完成一个任务:管理子模块和定义 Maven 依赖项的版本。这就像一个公司的大 boss 一样,只用制定方向战略,琐碎的业务就交给下面人(子模块)来办就好了。
那么顶层战略在哪里制定?其实就在 pom.xml 文件里,我们看一下 geekbang-coupon 的 pom 文件中都定义了哪些内容。
<!-- 已省略部分标签,完整内容请参考项目源代码 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.2</version>
</parent>
<groupId>com.geekbang</groupId>
<artifactId>geekbang-coupon</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>coupon-template-serv</module>
<module>coupon-calculation-serv</module>
<module>coupon-customer-serv</module>
<module>middleware</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.0</version>
</dependency>
<!-- 省略部分依赖项 -->
</dependencies>
</dependencyManagement>
在 pom 文件里有以下三个重点标签。
< parent > 标签
在 parent 标签中我们指定了 geekbang-coupon 项目的“父级依赖”为 spring-boot-starter-parent,这样一来,spring-boot-starter-parent 里定义的 Spring Boot 组件版本信息就会被自动带到子模块中。这种做法也是大多数 Spring Boot 项目的通用做法,不仅降低了依赖项管理的成本,也不需要担心各个组件间的兼容性问题。
< packaging > 标签
maven 的打包类型有三种:jar、war 和 pom。当我们指定 packaging 类型为 pom 时,意味着当前模块是一个“boss”,它只用关注顶层战略,即定义依赖项版本和整合子模块,不包含具体的业务实现。
< dependencymanagement > 标签
这个标签的作用和 < parent > 标签类似,两者都是将版本信息向下传递。dependencymanagement 是 boss 们定义顶层战略的地方,我们可以在这里定义各个依赖项的版本,当子项目需要引入这些依赖项的时候,只用指定 groupId 和 artifactId 即可,不用管 version 里该写哪个版本。
完成了 geekbang-coupon 依赖项的编写,接下来我们看看 coupon-template-serv 依赖项的编写。

编写 coupon-template-serv 依赖项

coupon-template-serv 是大 boss 下面的一个小头目,和 geekbang-coupon 一样,它的 packaging 类型也是 pom。我们说过 boss 只用管顶层战略,因此 coupon-temolate-serv 的 pom 文件内容很简单,只是定义了父级项目和子模块。
<!-- 已省略部分标签,完整内容请参考项目源代码 -->
<parent>
<artifactId>geekbang-coupon</artifactId>
<groupId>com.geekbang</groupId>
<version>1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>coupon-template-serv</artifactId>
<packaging>pom</packaging>
<modules>
<module>coupon-template-api</module>
<module>coupon-template-dao</module>
<module>coupon-template-impl</module>
</modules>
我们已经把 geekbang-coupon 和 coupon-template-serv 两个父级项目的依赖项添加完毕,接下来就去搭建 coupon-template-serv 下面的三个子模块。
coupon-template-api 模块存放了接口 Request 和 Response 的类模板,是另两个子模块需要依赖的公共类库,所以我就先从 coupon-template-api 开始项目构建。

搭建 coupon-template-api 模块

coupon-template-api 模块是专门用来存放公共类的仓库,我把 REST API 接口的服务请求和服务返回对象的 POJO 类放到了里面。在微服务领域,将外部依赖的 POJO 类或者 API 接口层单独打包是一种通用做法,这样就可以给外部依赖方提供一个“干净”(不包含非必要依赖)的接口包,为远程服务调用(RPC)提供支持。
在 coupon-template-api 项目的 pom 文件中,我只添加了少量的“工具类”依赖,比如 lombok、guava 和 validation-api 包等通用组件,这些工具类用来帮助我们自动生成代码并提供一些便捷的功能特性,具体的依赖项你可以参考项目源码。
首先,我们需要定义一个用来表示优惠券类型的 enum 对象,在 com.geekbang.coupon.template.api.enum 包下创建一个名为 CouponType 的枚举类。
@Getter
@AllArgsConstructor
public enum CouponType {
UNKNOWN("unknown", "0"),
MONEY_OFF("满减券", "1"),
DISCOUNT("打折", "2"),
RANDOM_DISCOUNT("随机减", "3")
LONELY_NIGHT_MONEY_OFF("晚间双倍优惠券", "4");
private String description;
// 存在数据库里的最终code
private String code;
public static CouponType convert(String code) {
return Stream.of(values())
.filter(bean -> bean.code.equalsIgnoreCase(code))
.findFirst()
.orElse(UNKNOWN);
}
}
CouponType 类定义了多个不同类型的优惠券,convert 方法可以根据优惠券的编码返回对应的枚举对象。这里还有一个“Unknown”类型的券,它专门用来对付故意输错 code 的恶意请求。
作为一个骨灰级程序员,我会认为所有需要用户输入的信息都是不可靠的,并且需要对各种意外输入做拦截、防范,这就是“防御性编程”的思维。工作的时间越久,人往往会变得越怂(都是被各种故障吓大的)。
接下来,我们创建两个用来定义优惠券模板规则的类,分别是 TemplateRule 和 Discount。我把它们放在 com.geekbang.coupon.template.api.beans.rules 包路径下。
TemplateRule 包含了两个规则,一是领券规则,包括每个用户可领取的数量和券模板的过期时间;二是券模板的计算规则。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TemplateRule {
// 可以享受的折扣
private Discount discount;
// 每个人最多可以领券数量
private Integer limitation;
// 过期时间
private Long deadline;
}
这里我强烈推荐你使用一键三连的 lombok 注解自动生成基础代码,它们分别是 Data、NoArgsConstructor 和 AllArgsConstructor。其中,Data 注解自动生成 getter、setter、toString 等方法,后两个注解分别生成无参构造器和全参构造器,省时省力省地盘。
TemplateRule 中的 Discount 成员变量定义了使用优惠券的规则,代码如下。
public class Discount {
// 对于满减券 - quota是减掉的钱数,单位是分
// 对于打折券 - quota是折扣(以100表示原价),90就是打9折, 95就是95折
// 对于随机立减券 - quota是最高的随机立减额
// 对于晚间特别优惠券 - quota是日间优惠额,晚间优惠翻倍
private Long quota;
// 订单最低要达到多少钱才能用优惠券,单位为分
private Long threshold;
}
从上面代码中可以看出,我使用 Long 来表示“金额”。对于境内电商行业来说,金额往往是以分为单位的,这样我们可以直接使用 Long 类型参与金额的计算,比如 100 就代表 100 分,也就是一块钱。这比使用 Double 到处转换 BigDecimal 省了很多事儿。
最后,我们在 com.geekbang.coupon.template.api.beans 包下创建一个名为 CouponTemplateInfo 的类,用来创建优惠券模板,代码如下:
// 已省略部分内容,完整内容请参考项目源代码
public class CouponTemplateInfo {
private Long id;
@NotNull
private String name; // 优惠券名称
@NotNull
private String desc; // 优惠券描述
@NotNull
private String type; // 优惠券类型(引用CouponType里的code)
private Long shopId; // 优惠券适用门店 - 若无则为全店通用券
@NotNull
private TemplateRule rule; // 优惠券使用规则
private Boolean available; // 当前模板是否为可用状态
}
在上面的代码中,我们应用了 jakarta.validate-api 组件的注解 @NotNull,对参数是否为 Null 进行了校验。如果请求参数为空,那么接口会自动返回 Bad Request 异常。当然,jakarta 组件还有很多可以用来做判定验证的注解,合理使用可以节省大量编码工作,提高代码可读性。
此外,你还会发现,CouponTemplateInfo 内封装了优惠券模板的基本信息,我们可以把优惠券模板当做一个“模具”,每一张优惠券都经由模具来制造,被制造出来的优惠券则使用 CouponInfo 对象来封装。
CouponInfo 对象包含了优惠券的模板信息、领券用户 ID、适用门店 ID 等属性。除此之外,我还在源码中定义了用来实现分页查找的对象,如果你特别感兴趣,可以到项目源码中查看完整的类定义。
到这里我们就完成了 coupon-template-api 项目的搭建,下面我们开始搭建 Dao 层模块:coupon-template-dao。它主要负责和数据库的对接、读取。

搭建 coupon-template-dao 模块

首先,我们把必要的依赖项添加到 coupon-template-dao 项目中,比较关键的 maven 依赖项有以下几个。
coupon-template-api: 引入 api 包下的公共类;
spring-boot-starter-data-jpa: 添加 spring-data-jpa 的功能进行 CRUD 操作;
mysql-connector-java: 引入 mysql 驱动包,驱动版本尽量与 mysql 版本保持一致。
接下来,我们在 com.geekbang.coupon.template.dao.entity 目录下创建了一个数据库实体对象的 Java 类:CouponTemplate。
// 完整内容请参考源代码
@Entity
@Builder
@EntityListeners(AuditingEntityListener.class)
@Table(name = "coupon_template")
public class CouponTemplate implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
// 状态是否可用
@Column(name = "available", nullable = false)
private Boolean available;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "description", nullable = false)
private String description;
// 适用门店-如果为空,则为全店满减券
@Column(name = "shop_id")
private Long shopId;
// 优惠券类型
@Column(name = "type", nullable = false)
@Convert(converter = CouponTypeConverter.class)
private CouponType category;
// 创建时间,通过@CreateDate注解自动填值(需要配合@JpaAuditing注解在启动类上生效)
@CreatedDate
@Column(name = "created_time", nullable = false)
private Date createdTime;
// 优惠券核算规则,平铺成JSON字段
@Column(name = "rule", nullable = false)
@Convert(converter = RuleConverter.class)
private TemplateRule rule;
}
在 CouponTemplate 上,我们运用了 javax.persistence 包和 Spring JPA 包的标准注解,对数据库字段进行了映射,我挑几个关键注解说道一下。
Entity:声明了“数据库实体”对象,它是数据库 Table 在程序中的映射对象;
Table:指定了 CouponTemplate 对应的数据库表的名称;
ID/GeneratedValue:ID 注解将某个字段定义为唯一主键,GeneratedValue 注解指定了主键生成策略;
Column:指定了每个类属性和数据库字段的对应关系,该注解还支持非空检测、对 update 和 create 语句进行限制等功能;
CreatedDate:自动填充当前数据的创建时间;
Convert:如果数据库中存放的是 code、string、数字等等标记化对象,可以使用 Convert 注解指定一个继承自 AttributeConverter 的类,将 DB 里存的内容转化成一个 Java 对象。
这里我要补充一点,其实 JPA 也支持一对多、多对多的级联关系(ManyToOne、OneToOne 等注解),但是你发现我并没有在项目中使用,原因是这些注解背后有很多隐患。过深的级联层级所带来的 DB 层压力可能会在洪峰流量下被急剧放大,而 DB 恰恰是最不抗压的一环。所以,我们很少在一些一二线大厂的超高并发项目中看到级联配置的身影。
我的经验是尽可能减少级联配置,用单表查询取而代之,如果一个查询需要 join 好几张表,最好的做法就通过重构业务逻辑来简化 DB 查询的复杂度。
最后,我们来到定义 DAO 的地方,借助 Spring Data 的强大功能,我们只通过接口名称就可以声明一系列的 DB 层操作。我们先来看一下 CouponTemplateDao 这个类的代码。
public interface CouponTemplateDao
extends JpaRepository<CouponTemplate, Long> {
// 根据Shop ID查询出所有券模板
List<CouponTemplate> findAllByShopId(Long shopId);
// IN查询 + 分页支持的语法
Page<CouponTemplate> findAllByIdIn(List<Long> Id, Pageable page);
// 根据shop ID + 可用状态查询店铺有多少券模板
Integer countByShopIdAndAvailable(Long shopId, Boolean available);
/**
* 将优惠券设置为不可用
*/
@Modifying
@Query("update CouponTemplate c set c.available = 0 where c.id = :id")
int makeCouponUnavailable(@Param("id") Long id);
// 完整方法请至源码查看
}
看了这段代码,你一定在想这里都是查询数据的场景,那么“增删改”的方法在哪里?
其实,这些方法都在 CouponTemplateDao 所继承的 JpaRepository 类中。这个父类就像一个百宝箱,内置了各种各样的数据操作方法。我们可以通过内置的 save 方法完成对象的创建和更新,也可以使用内置的 delete 方法删除数据。
此外,它还提供了对“查询场景”的丰富支持,除了通过 ID 查询以外,我们还可以使用三种不同的方式查询数据。
通过接口名查询:将查询语句写到接口的名称中;
通过 Example 对象查询:构造一个模板对象,使用 findAll 方法来查询;
自定义查询:通过 Query 注解自定义复杂查询语句。
在 CouponTemplateDao 中,第一个方法 findAllByShopId 就是通过接口名查询的例子,jpa 使用了一种约定大于配置的思想,你只需要把要查询的字段定义在接口的方法名中,在你发起调用时后台就会自动转化成可执行的 SQL 语句。构造方法名的过程需要遵循 < 起手式 >By< 查询字段 >< 连接词 > 的结构。
起手式:以 find 开头表示查询,以 count 开头表示计数;
查询字段:字段名要保持和 Entity 类中定义的字段名称一致;
连接词:每个字段之间可以用 And、Or、Before、After 等一些列丰富的连词串成一个查询语句。
以接口名查询的方式虽然很省事儿,但它面对复杂查询却力不从心,一来容易导致接口名称过长,二来维护起来也挺吃力的。所以,对于复杂查询,我们可以使用自定义 SQL、或者 Example 对象查找的方式。
关于自定义 SQL,你可以参考 CouponTemplateDao 中的 makeCouponUnavailable 方法,我将 SQL 语句定义在了 Query 注解中,通过参数绑定的方式从接口入参处获取查询参数,这种方式是最接近 SQL 编码的 CRUD 方式。
Example 查询的方式也很简单,构造一个 CouponTemplate 的对象,将你想查询的字段值填入其中,做成一个查询模板,调用 Dao 层的 findAll 方法即可,这里留给你自己动手验证。
couponTemplate.setName("查询名称");
templateDao.findAll(Example.of(couponTemplate));
现在,API 和 Dao 层都已经准备就绪,万事俱备只差最后的业务逻辑层了,接下来我们去搭建 coupon-template-impl 模块。

搭建 coupon-template-impl 模块

coupon-template-impl 是 coupon-template-serv 下的一个子模块,也是实现业务逻辑的地方。从依赖管理的角度,它引入了 coupon-template-api 和 coupon-template-dao 两个内部依赖项到 pom.xml。
当然,我们也需要加入几个外部依赖项,你可以参考项目的 pom.xml 源代码获取完整的依赖项列表。
首先,我们先来定义 Service 层的接口类:CouponTemplateService。在这个接口中,我们定义了优惠券创建、查找优惠券和修改优惠券可用状态的方法。
public interface CouponTemplateService {
// 创建优惠券模板
CouponTemplateInfo createTemplate(CouponTemplateInfo request);
// 通过模板ID查询优惠券模板
CouponTemplateInfo loadTemplateInfo(Long id);
// 克隆券模板
CouponTemplateInfo cloneTemplate(Long templateId);
// 模板查询(分页)
PagedCouponTemplateInfo search(TemplateSearchParams request);
// 删除券模板
void deleteTemplate(Long id);
//批量读取模板
Map<Long, CouponTemplateInfo> getTemplateInfoMap(Collection<Long> ids);
// 完整方法列表请至源码查看
}
由于这部分比较简单,就是通过 CouponTemplateDao 层来实现优惠券模板的增删改查,这里我就不展开介绍实现层代码了,你可以参考源码中的 CouponTemplateServiceImpl 类。
不过,我建议你不要直接 copy 源码,先尝试自己实现这几个 Service 方法,写完之后再和我的源码做比较,看一看有哪些可以改进的地方。
接下来,我们创建 CouponTemplateController 类对外暴露 REST API,可以借助 spring-web 注解来完成,具体代码如下。
@Slf4j
@RestController
@RequestMapping("/template")
public class CouponTemplateController {
@Autowired
private CouponTemplateService couponTemplateService;
// 创建优惠券
@PostMapping("/addTemplate")
public CouponTemplateInfo addTemplate(@Valid @RequestBody CouponTemplateInfo request) {
log.info("Create coupon template: data={}", request);
return couponTemplateService.createTemplate(request);
}
// 克隆券模板
@PostMapping("/cloneTemplate")
public CouponTemplateInfo cloneTemplate(@RequestParam("id") Long templateId) {
log.info("Clone coupon template: data={}", templateId);
return couponTemplateService.cloneTemplate(templateId);
}
// 读取优惠券
@GetMapping("/getTemplate")
public CouponTemplateInfo getTemplate(@RequestParam("id") Long id){
log.info("Load template, id={}", id);
return couponTemplateService.loadTemplateInfo(id);
}
// 搜索模板(支持分页查询)
@PostMapping("/search")
public PagedCouponTemplateInfo search(@Valid @RequestBody TemplateSearchParams request) {
log.info("search templates, payload={}", request);
return couponTemplateService.search(request);
}
// ... 完整代码请至源码查看
}
在这里,Controller 类中的注解来自 spring-boot-starter-web 依赖项,通过这些注解将服务以 RESTful 接口的方式对外暴露。现在,我们来了解下上述代码里,服务寻址过程中的三个重要注解:
RestController:用来声明一个 Controller 类,加载到 Spring Boot 上下文;
RequestMapping:指定当前类中所有方法在 URL 中的访问路径的前缀;
Post/Get/PutMapping:定义当前方法的 HTTP Method 和访问路径。
项目启动类是最后的代码部分,我们在 com.geekbang.coupon.template 下创建一个 Application 类作为启动程序的入口,并在这个类的头上安上 SpringBoot 的启动注解。
@SpringBootApplication
@EnableJpaAuditing
@ComponentScan(basePackages = {"com.geekbang"})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
SpringBootApplication 注解会自动开启包路径扫描,并启动一系列的自动装配流程(AutoConfig)。在默认情况下,Spring Boot 框架会扫描启动类所在 package 下的所有类,并在上下文中创建受托管的 Bean 对象,如果我们想加载额外的扫包路径,只用添加 ComponentScan 注解并指定 path 即可。
所有代码环节全部完工后,我们还剩最后的画龙点睛之笔:创建配置文件 application.yml,它位于 src/main/resources 文件夹下。Spring Boot 支持多种格式的配置文件,这里我们顺应主流,使用 yml 格式。
# 项目的启动端口
server:
port: 20000
spring:
application:
# 定义项目名称
name: coupon-template-serv
datasource:
# mysql数据源
username: root
# password: 这里写上你自己的密码
url: jdbc:mysql://127.0.0.1:3306/geekbang_coupon_db?autoReconnect=true&useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&zeroDateTimeBehavior=convertToNull&serverTimezone=UTC
# 指定数据源DataSource类型
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
# 数据库连接池参数配置,比如池子大小、超时时间、是否自动提交等等
hikari:
pool-name: GeekbangCouponHikari
connection-timeout: 5000
idle-timeout: 30000
maximum-pool-size: 10
minimum-idle: 5
max-lifetime: 60000
auto-commit: true
jpa:
show-sql: true
hibernate:
# 在生产环境全部为none,防止ddl结构被自动执行,破坏生产数据
ddl-auto: none
# 在日志中打印经过格式化的SQL语句
properties:
hibernate.format_sql: true
hibernate.show_sql: true
open-in-view: false
在配置文件中,有一个地方需要你多加注意,那就是 jdbc 连接串(spring.datasource.url)。不同版本的 MySQL 对连接串中的参数有不同的要求。
如果你发现项目启动过程中抛出了 MySQL 连接报错,一定记得检查自己的 MySQL 版本,检查是否缺失了某些参数(比如 MySQL 8.x 版本下要求传入 serverTimezone 参数)。如果你本地安装的 MySQL 版本早于 8.x 系列,我推荐你重新安装和我一样的 MySQL 8.0.27 版本,这样就不会碰到兼容性问题了。
好,到这里,我们优惠券平台项目的第一个模块 coupon-template-serv 就搭建完成了,你可以在本地启动项目并通过 Postman 发起调用。我已经将 Postman API 集合上传到了这个Gitee 源码库中的“资源文件”目录下,文件名为“Spring Boot 阶段.postman_collection.json”,你可以导入到自己本地的 Postman 中使用。
现在,我们来回顾一下这节课的重点内容。

总结

今天我带你搭建了整个优惠券服务的整体项目结构,并且用 Spring Boot 快速落地了优惠券模板服务。如果你在自己的项目中还在使用繁琐的 sql 资源文件来操作数据库,不妨升级成 coupon-template-dao 中使用的 spring-data-jpa 来简化 DB 操作。spring-data-jpa 的功能特性也折射出 Spring 框架的发展趋势:约定大于配置,且越来越轻量级。
在学习这节课的时候,我希望你不要只满足于把项目跑起来就万事大吉了,你还要做一些思考和总结沉淀,想一想如何能把课程中的一些技术点应用在自己的项目中。我在这节课分享了很多开发小技巧,比如防御性编程、代码自动生成、金额计算、如何简化数据校验、级联关系的误区等,这些都可以作为你的开发素材。
希望你能够动起手来,顺着这节课程的内容动手搭建整个服务,不要直接照搬源码本地执行一下就完事儿了,只有上手实际搭建项目我们才能了解技术细节、积累排查问题的经验。要知道,纸上得来终觉浅,绝知此事要躬行。
在下一节课中,我会带你搭建 coupon-calculation-ser 和 coupon-customer-serv,构建一个完整的优惠券平台 Spring Boot 项目。

思考题

最后,请你思考一个问题:
级联查询很容易引发性能问题,你在自己的项目中遇到最复杂的 SQL 是什么?然后,请你进一步做个思考:如果这条 SQL 的调用量激增,你该如何进行优化?欢迎你“显摆”出来,我在留言区等你。
好啦,这节课就结束啦。也欢迎你把这节课分享给更多对 Spring Cloud 感兴趣的朋友。我是姚秋辰,我们下节课再见!
分享给需要的人,Ta订阅后你可得20现金奖励
生成海报并分享

赞 0

提建议

上一篇
04 | 十八般兵器:如何搭建项目所需的开发环境?
下一篇
06 | 牛刀小试:如何搭建优惠券计算服务和用户服务?
 写留言

精选留言(17)

  • 暮雨yl晨曦
    2021-12-22
    仔仔细细review了老师的coupon-template-serv下的代码,有几个问题请教老师。
    1.@ComponentScan(basePackages = {"com.geekbang"}),我仔细核对了,所有子module的代码都是在包com.geekbang.coupon.template下,启动类也在这个包下,所以这个注解是可以去掉的。我猜测老师是为了演示,告诉大家这个注解是这么用的。
    2.我们现在项目中都被强制要求不允许用fastjson,看到项目中用了这个。其实springboot是有jackson和gson的。我猜测老师也是为了方便项目演示而使用fastjson。jackson和gson上手比fastjson稍微麻烦点。
    3.关于dependencyManagement中,lombok、commons-lang3、commons-codec、jakarta.validation-api,这些是可以去掉的,因为在SpringBoot中已经定义了。老师可以看spring-boot-dependencies-{version}.pom,里面都有。我估计老师是从老项目中copy出来的,springboot版本更新了,但是没关注到这些依赖也已经被springboot管理起来了。另外,guava 16的版本有点低,我自己换成最新的版本了。目前的代码中还没看到有使用的。
    4.现在启动类都是类名Application,我还是喜欢分开,比如CouponTemplateApplication。为了方便我也是放到同一个git目录下。所以就会导致idea那边默认的类名:Application、Application(1)、Application(2)。。。。
    5.一些细节性的代码,比如function能立即返回,但是还是先声明了变量,这是为了方便调试吗?我为了调试方便一些我也会这么写,但是调试完成会改成立即返回的形式。另外,一些import、无用的注释之类的并没有去掉、Long->可以使用long、一些属性可以声明成final等。强迫症看着难受。
    6.entity中,当database column name和entity里的字段名一样的话,是可以不用特意加name="xxxx"的。
    展开

    作者回复: 1. 对滴,同学太懂我了
    2. 哈哈没错,确实推荐用jackson和gson来替代,像我从阿里出来的人都有职业病潜意识里就用了fastjson,中毒不轻。fastjson早期用了很多字节码技术提高性能,埋了不少坑,以前在集团也是bug不断,现在其他json组件的解析性能都上来了,确实没必要用fastjson.
    3. 对的,我直接从老项目里copy出来的,多谢同学提醒
    4. 是的分开命名确实在IDEA本地启动的时候会方便一些
    5. 看出同学还是比较细节的,强迫症是程序员的一个很好的特质。一般我习惯不立即返回,方便以后打debug日志输出参数
    6. 这是防御性编程的习惯,以防日后code refactor的时候更改属性名称后SQL挂掉

    共 5 条评论
    10
  • 卟卟熊
    2021-12-22
    就像作者说的那样,我们不相信任何的代码,我们必须亲眼看到执行的每一句sql,hibernate就因为全自动,造成对用户完全的黑盒是一种很可怕的事情。
    共 1 条评论
    4
  • gevin
    2021-12-22
    Lombok的@Data 注解,我一般只用在VO和DO对象上,BO上会刻意绕开在类上加@Setter 注解。
    使用@Data,主要是出于写代码偷懒,VO和DO上业务逻辑少,主要是数据载体的作用,放弃对象一定的封装性问题不大;而BO中要包含较多的业务逻辑,我通常会比较严格的遵守面向对象的要求
    展开
    4
  • 李峰
    2021-12-22
    orm框架,我建议换成mybatis,现在很多企业实现中都是mybatis

    作者回复: JPA才是orm框架标准,mybatis只能算是半自动。跟着spring的方向走,把手动挡的mybatis换成自动挡spring-data-jpa,简化轻量级和快速开发是主方向,在微服务的领域模型拆分下,不太需要从前那种复杂sql扮演跨多domain查询的角色

    共 5 条评论
    4
  • 前行
    2021-12-22
    用不习惯 jpa 的,可以使用 mybatis-plus
    2
  • 卟卟熊
    2021-12-22
    虽然作者说的是hibernate是趋势,也秉承了约定大于配置,可是想法太超前了,毕竟其他开发人员没有这种超前的意识啊,还是希望用mybatis,老师这个怎么说服组员呢?

    作者回复: mybatis是开手动挡,spring-data-jpa是开自动挡,大家先体验一下自动挡的快感

    2
  • C
    2021-12-23
    我的经验是尽可能减少级联配置,用单表查询取而代之,如果一个查询需要 join 好几张表,最好的做法就通过重构业务逻辑来简化 DB 查询的复杂度。


    这个能详细解说下吗?
    展开

    作者回复: 像join,还有笛卡尔积之类的运算都是很消耗DB资源的,调出sql执行计划看选择的索引策略和时间指标,对一些低效步骤进行拆分,将一个大sql拆成若干个子sql,用代码逻辑替换sql逻辑。 - 这是单体应用下的拆分思路,至于微服务架构,如果领域划分合理的话,不太会出现跨一串表的查询,取而代之是跨服务api调用。报表类需求需要跨domain的场景,通常由数据团队,用各种EDL、data lake等等一堆大数据技术来搭建。

    1
  • PureClouds
    2021-12-22
    有人知道微服务从小白到专家的GitHub地址?
    1
  • Layne
    2021-12-22
    遇到这样一个场景:将两张表中的数据进行union all 然后按照指定字段排序,然后分页返回。而且两张表分别需要关联很多表来带出一些附属字段信息。本来单纯两张表里总共数据库也就十几万的量,后面经过一些join和union all,查看了一下SQL的explain,统计数据量到了几百万。后面做了下调整:
    1.将两张表的数据提前整合并排序,然后缓存起来;
    2.将关联查询放到业务代码里来拼接,也就是分开几条SQL来查,不用全量join,只需要按分页数量的大小来查相应数量的关联信息。
    展开

    作者回复: 很赞,这两个调整都是很正确的改进方向!而且这里提到了执行计划,sql调优的重点。同学这条经验总结很到位

    1
  • 201
    2021-12-25
    哎,快更新呀。

    作者回复: 男人不能太快呀:-)

  • 张立华
    2021-12-25
    老师,你好。 我觉coupon-template-dao依赖coupon-template-api 不太好吧。

    coupon-template-dao中应该定义数据。 coupon-template-api 中则是api 请求和返回的数据。

    作者回复: 我在教学项目里简化了值传递的分层,对于商业大型项目应该把vo和dto分别做定义。不过实践里往往不会遵循100%不越界,像enum之类的简单枚举,在api里定义一个之后分享给dao层引用是很常见的

  • 曙光
    2021-12-24
    controller 层为什么放到 impl 层呢?

    作者回复: 微服务甚至可以把controller去掉,服务层挂上@ResponseBody直接对外暴露service,controller是mvc时代的产物,现在必要性不大了。看个人习惯,如果偏向分一个独立的controller module也可以

    共 2 条评论
  • 曙光
    2021-12-24
    还是埋下一些坑的,很久没用jpa 了,maven 多模块了,@EntityScan(basePackages = {"com.geekbang.coupon.template.dao.entity"})
    @EnableJpaRepositories(basePackages = {"com.geekbang.coupon.template.dao"})

    作者回复: 只要注意默认扫包路径和jpa类在一块就行,如果不在一个base目录下,像customer服务这种三合一应用,就配置一个更上层的base packages

    共 2 条评论
  • Avalon
    2021-12-24
    老师,现在coupon项目一层套一层,请问用git应该怎么分仓库管理合适?

    作者回复: 我们在实践里面一般还是要看各个公司的标准,每个微服务是要使用独立的repo来管理的,我课程里是为了大家下载代码方便所以才把几个服务放到了同一个repo。

    我建议如果领域划分已经比较细粒度了,每个微服务的复杂度已经控制在一个相对合理的范围内,那么同一个微服务的dao层和service层可以放在一个repo里管理,API层我建议单独可以分成一个独立的repo来管理

  • Michael
    2021-12-23
    GET /template/{id}
    POST /template/{id}/clone
    GET /template/search
    我觉得这样设计是不是更符合RESTful的定义一些

    作者回复: 是的这个是更规范的path name,我没有严格按照REST规范来起名字,主要是帮助大家通过url理解接口干了什么事儿,并没有将接口语义放到HTTP method中来提现。

    如果更加严格的话,往往以一个大的domain方式开头,比如说优惠券和营销规则在大的domain划分下属于marketing domain,那么会以/marketing/v1/template/search-template (通常最后的url不是非动词,而是要有完整语义)。像我现在的公司就是极其严格遵循restful API规范的公司,每次定义API起名字要经过半个月审核。

    而且REST规范太过于教课书化,尤其是用作修改场景的PUT接口,严格的Restful style的PUT接口不管从实现还是接口定义上来说,都非常限制生产力,如果严格遵循规范开发PUT接口会给开发带来很多麻烦。

  • Michael
    2021-12-23
    老师,你这URL的设计不是很规范啊。
    POST /template/addTemplate -> POST /template

    作者回复: 是的,我没有严格按照REST规范来起名字,主要是帮助大家通过url理解接口干了什么事儿,并没有将接口语义放到HTTP method中来提现。

    而且REST规范太过于教课书化,如果严格遵循会给开发带来很多麻烦,尤其是用作修改场景的PUT接口,严格的PUT接口不管从实现还是接口定义上来说,都非常限制生产力

  • kimoti
    2021-12-22
    请问老师假如我新增一种优惠券类型,能做到只在枚举类里新增而不用修改其他任何代码吗?

    作者回复: 这个目前还办不到哈,新增类型是需要写一些具体代码实现的